<?php

/**
 * Loads files for admin and frontend.
 *
 * @author     Time.ly Network Inc.
 * @since      2.0
 *
 * @package    AI1EC
 * @subpackage AI1EC.Theme
 */
class Ai1ec_Theme_Loader {

	/**
	 * @const string Name of option which forces theme clean-up if set to true.
	 */
    const OPTION_FORCE_CLEAN = 'ai1ec_clean_twig_cache';

	/**
	 * @const string Prefix for theme arguments filter name.
	 */
	const ARGS_FILTER_PREFIX = 'ai1ec_theme_args_';

	/**
	 * @var array contains the admin and theme paths.
	 */
	protected $_paths = array(
		'admin' => array( AI1EC_ADMIN_PATH => AI1EC_ADMIN_URL ),
		'theme' => array(),
	);

	/**
	 * @var Ai1ec_Registry_Object The registry Object.
	 */
	protected $_registry;

	/**
	 * @var array Array of Twig environments.
	 */
	protected $_twig = array();

	/**
	 * @var bool Whether this theme uses .php templates instead of .twig
	 */
	protected $_legacy_theme = false;

	/**
	 * @var bool Whether this theme is a child of the default theme
	 */
	protected $_child_theme = false;

	/**
	 * @var bool Whether this theme is a core theme
	 */
	protected $_core_theme = false;

	/**
	 * @return boolean
	 */
	public function is_legacy_theme() {
		return $this->_legacy_theme;
	}

	/**
	 *
	 * @param $registry Ai1ec_Registry_Object
	 *       	 The registry Object.
	 */
	public function __construct(
			Ai1ec_Registry_Object $registry
		) {
		$this->_registry         = $registry;
		$option                  = $this->_registry->get( 'model.option' );
		$theme                   = $option->get( 'ai1ec_current_theme' );
		$this->_legacy_theme     = (bool)$theme['legacy'];

		// Find out if this is a core theme.
		$core_themes             = explode( ',', AI1EC_CORE_THEMES );
		$this->_core_theme       = in_array( $theme['stylesheet'], $core_themes );

		// Default theme's path is always the last in the list of paths to check,
		// so add it first (path list is a stack).
		$this->add_path_theme(
			AI1EC_DEFAULT_THEME_PATH . DIRECTORY_SEPARATOR,
			AI1EC_THEMES_URL . '/' . AI1EC_DEFAULT_THEME_NAME . '/'
		);

		// If using a child theme, set flag and push its path to top of stack.
		if ( AI1EC_DEFAULT_THEME_NAME !== $theme['stylesheet'] ) {
			$this->_child_theme = true;
			$this->add_path_theme(
				$theme['theme_dir'] . DIRECTORY_SEPARATOR,
				$theme['theme_url'] . '/'
			);
		}
	}

	/**
	 * Runs the filter for the specified filename just once
	 *
	 * @param array $args
	 * @param string $filename
	 * @param boole $is_admin
	 *
	 * @return array
	 */
	public function apply_filters_to_args( array $args, $filename, $is_admin ) {
		return  apply_filters(
			self::ARGS_FILTER_PREFIX . $filename,
			$args,
			$is_admin
		);
	}

	/**
	 * Adds file search path to list. If an extension is adding this path, and
	 * this is a custom child theme, inserts its path at the second index of the
	 * list. Else pushes it onto the top of the stack.
	 *
	 * @param string $target       Name of path purpose, i.e. 'admin' or 'theme'.
	 * @param string $path         Absolute path to the directory to search.
	 * @param string $url          URL to the directory represented by $path.
	 * @param string $is_extension Whether an extension is adding this page.
	 *
	 * @return bool Success.
	 */
	public function add_path( $target, $path, $url, $is_extension = false ) {
		if ( ! isset( $this->_paths[$target] ) ) {
			// Invalid target.
			return false;
		}

		$path = apply_filters( 'ai1ec_theme_loader_add_path_file', $path, $url,  $target, $is_extension );
		$url  = apply_filters( 'ai1ec_theme_loader_add_path_http', $url,  $path, $target, $is_extension );
		// New element to insert into associative array.
		$new = array( $path => $url );

		if (
			true  === $is_extension &&
			true  === $this->_child_theme &&
			false === $this->_core_theme
		) {
			// Special case: extract first element into $head and insert $new after.
			$head = array_splice( $this->_paths[$target], 0, 1 );
		} else {
			// Normal case: $new gets pushed to the top of the array.
			$head = array();
		}

		$this->_paths[$target] = $head + $new + $this->_paths[$target];
		return true;
	}

	/**
	 * Add admin files search path.
	 *
	 * @param string $path Path to admin template files.
	 * @param string $url  URL to the directory represented by $path.
	 *
	 * @return bool Success.
	 */
	public function add_path_admin( $path, $url ) {
		return $this->add_path( 'admin', $path, $url );
	}

	/**
	 * Add theme files search path.
	 *
	 * @param string $path         Path to theme template files.
	 * @param string $url          URL to the directory represented by $path.
	 * @param string $is_extension Whether an extension is adding this path.
	 *
	 * @return bool Success.
	 */
	public function add_path_theme( $path, $url, $is_extension = false ) {
		return $this->add_path( 'theme', $path, $url, $is_extension );
	}

	/**
	 * Extension registration hook to automatically add file paths.
	 *
	 * NOTICE: extensions are expected to exactly replicate Core directories
	 * structure. If different extension is to be developed at some point in
	 * time - this will have to be changed.
	 *
	 * @param string $path Absolute path to extension's directory.
	 * @param string $url  URL to directory represented by $path.
	 *
	 * @return Ai1ec_Theme_Loader Instance of self for chaining.
	 */
	public function register_extension( $path, $url ) {
		$D = DIRECTORY_SEPARATOR; // For readability.

		// Add extension's admin path.
		$this->add_path_admin(
			$path . $D .'public' . $D . 'admin' . $D,
			$url . '/public/admin/'
		);

		// Add extension's theme path(s).
		$option = $this->_registry->get( 'model.option' );
		$theme  = $option->get( 'ai1ec_current_theme' );

		// Default theme's path is always later in the list of paths to check,
		// so add it first (path list is a stack).
		$this->add_path_theme(
			$path . $D . 'public' . $D . AI1EC_THEME_FOLDER . $D .
				AI1EC_DEFAULT_THEME_NAME . $D,
			$url . '/public/' . AI1EC_THEME_FOLDER . '/' . AI1EC_DEFAULT_THEME_NAME .
				'/',
			true
		);

		// If using a core child theme, set flag and push its path to top of stack.
		if ( true === $this->_child_theme && true === $this->_core_theme ) {
			$this->add_path_theme(
				$path . $D . 'public' . $D . AI1EC_THEME_FOLDER . $D .
					$theme['stylesheet'] . $D,
				$url . '/public/' . AI1EC_THEME_FOLDER . '/' . $theme['stylesheet'] .
					'/',
				true
			);
		}
		return $this;
	}

	/**
	 * Get the requested file from the filesystem.
	 *
	 * Get the requested file from the filesystem. The file is already parsed.
	 *
	 * @param string $filename        Name of file to load.
	 * @param array  $args            Map of variables to use in file.
	 * @param bool   $is_admin        Set to true for admin-side views.
	 * @param bool   $throw_exception Set to true to throw exceptions on error.
	 * @param array  $paths           For PHP & Twig files only: list of paths to use instead of default.
	 *
	 * @throws Ai1ec_Exception If File is not found or not possible to handle.
	 *
	 * @return Ai1ec_File_Abstract An instance of a file object with content parsed.
	 */
	public function get_file(
		$filename,
		$args            = array(),
		$is_admin        = false,
		$throw_exception = true,
		array $paths     = null
	) {
		$dot_position = strrpos( $filename, '.' ) + 1;
		$ext          = substr( $filename, $dot_position );
		$file         = false;

		switch ( $ext ) {
			case 'less':
			case 'css':
				$filename_base = substr( $filename, 0, $dot_position - 1);
				$file          = $this->_registry->get(
					'theme.file.less',
					$filename_base,
					array_keys( $this->_paths['theme'] ) // Values (URLs) not used for CSS
				);
				break;

			case 'png':
			case 'gif':
			case 'jpg':
				$paths = $is_admin ? $this->_paths['admin'] : $this->_paths['theme'];
				$file  = $this->_registry->get(
					'theme.file.image',
					$filename,
					$paths // Paths => URLs needed for images
				);
				break;

			case 'php':
				$args = apply_filters(
					self::ARGS_FILTER_PREFIX . $filename,
					$args,
					$is_admin
				);
				if ( null === $paths ) {
					$paths = $is_admin ? $this->_paths['admin'] : $this->_paths['theme'];
					$paths = array_keys( $paths ); // Values (URLs) not used for PHP
				}
				$args['is_legacy_theme'] = $this->_legacy_theme;
				$file                    = $this->_registry->get(
					'theme.file.php',
					$filename,
					$paths,
					$args
				);
				break;

			case 'twig':
				$args = apply_filters(
					self::ARGS_FILTER_PREFIX . $filename,
					$args,
					$is_admin
				);

				if ( null === $paths ) {
					$paths = $is_admin ? $this->_paths['admin'] : $this->_paths['theme'];
					$paths = array_keys( $paths ); // Values (URLs) not used for Twig
				}
				if ( true === $this->_legacy_theme && ! $is_admin ) {
					$filename = substr( $filename, 0, $dot_position - 1);
					$file     = $this->_get_legacy_file(
						$filename,
						$args,
						$paths
					);
				} else {
					$file = $this->_registry->get(
						'theme.file.twig',
						$filename,
						$args,
						$this->_get_twig_instance( $paths, $is_admin )
					);
				}
				break;

			default:
				throw new Ai1ec_Exception(
					sprintf(
						Ai1ec_I18n::__( "We couldn't find a suitable loader for filename with extension '%s'" ),
						$ext
					)
				);
				break;
		}

		// here file is a concrete class otherwise the exception is thrown
		if ( ! $file->process_file() && true === $throw_exception ) {
			throw new Ai1ec_Exception(
				'The specified file "' . $filename . '" doesn\'t exist.'
			);
		}
		return $file;
	}

	/**
	 * Reuturns loader paths.
	 *
	 * @return array Loader paths.
	 */
	public function get_paths() {
		return $this->_paths;
	}

	/**
	 * Tries to load a PHP file from the theme. If not present, it falls back to
	 * Twig.
	 *
	 * @param string $filename Filename to locate
	 * @param array  $args     Args to pass to template
	 * @param array  $paths    Array of paths to search
	 *
	 * @return Ai1ec_File_Abstract
	 */
	protected function _get_legacy_file( $filename, array $args, array $paths ) {
		$php_file = $filename . '.php';
		$php_file = $this->get_file( $php_file, $args, false, false, $paths );

		if ( false === $php_file->process_file() ) {
			$twig_file = $this->_registry->get(
				'theme.file.twig',
				$filename . '.twig',
				$args,
				$this->_get_twig_instance( $paths, false )
			);

			// here file is a concrete class otherwise the exception is thrown
			if ( ! $twig_file->process_file() ) {
				throw new Ai1ec_Exception(
					'The specified file "' . $filename . '" doesn\'t exist.'
				);
			}
			return $twig_file;
		}
		return $php_file;
	}

	/**
	 * Get Twig instance.
	 *
	 * @param bool $is_admin Set to true for admin views.
	 * @param bool $refresh  Set to true to get fresh instance.
	 *
	 * @return Twig_Environment Configured Twig instance.
	 */
	public function get_twig_instance( $is_admin = false, $refresh = false ) {
		if ( $refresh ) {
			unset( $this->_twig );
		}
		$paths = $is_admin ? $this->_paths['admin'] : $this->_paths['theme'];
		$paths = array_keys( $paths ); // Values (URLs) not used for Twig
		return $this->_get_twig_instance( $paths, $is_admin );
	}

	/**
	 * Get cache dir for Twig.
	 *
	 * @param bool $rescan Set to true to force rescan
	 *
	 * @return string|bool Cache directory or false
	 */
	public function get_cache_dir( $rescan = false ) {
		$settings         = $this->_registry->get( 'model.settings' );
		$ai1ec_twig_cache = $settings->get( 'twig_cache' );
		if (
			! empty( $ai1ec_twig_cache ) &&
			false === $rescan
		) {
			return ( AI1EC_CACHE_UNAVAILABLE === $ai1ec_twig_cache )
				? false
				: $ai1ec_twig_cache;
		}
		$path          = false;
		$scan_dirs     = array( AI1EC_TWIG_CACHE_PATH );
		if ( apply_filters( 'ai1ec_check_static_dir', true ) ) {
			$filesystem    = $this->_registry->get( 'filesystem.checker' );
			$upload_folder = $filesystem->get_ai1ec_static_dir_if_available();
			if ( '' !== $upload_folder ) {
				$scan_dirs[] = $upload_folder;
			}
		}
		foreach ( $scan_dirs as $dir ) {
			if ( $this->_is_dir_writable( $dir ) ) {
				$path = $dir;
				break;
			}
		}

		$settings->set(
			'twig_cache',
			false === $path ? AI1EC_CACHE_UNAVAILABLE : $path
		);
		if ( false === $path ) {
			/* @TODO: move this to Settings -> Advanced -> Cache and provide a nice message */
		}
		return $path;
	}

	/**
	 * After upgrade clean cache if it's not default.
	 *
	 * @return void Method doesn't return
	 */
	public function clean_cache_on_upgrade() {
		if ( ! apply_filters( 'ai1ec_clean_cache_on_upgrade', true ) ) {
			return;
		}
		$model_option = $this->_registry->get( 'model.option' );
		if ( $model_option->get( self::OPTION_FORCE_CLEAN, false ) ) {
			$model_option->set( self::OPTION_FORCE_CLEAN, false );
			$cache = realpath( $this->get_cache_dir() );
			if ( 0 === strcmp( $cache, realpath( AI1EC_TWIG_CACHE_PATH ) ) ) {
				return;
			}
			if (
				! $this->_registry->get(
					'theme.compiler'
				)->clean_and_check_dir( $cache )
			) {
				$this->_registry->get( 'twig.cache' )->set_unavailable( $cache );
			}
		}
	}

	/**
	 * This method whould be in a factory called by the object registry.
	 * I leave it here for reference.
	 *
	 * @param array $paths Array of paths to search
	 * @param bool  $is_admin whether to use the admin or not admin Twig instance
	 *
	 * @return Twig_Environment
	 */
	protected function _get_twig_instance( array $paths, $is_admin ) {
		$instance = $is_admin ? 'admin' : 'front';
		if ( ! isset( $this->_twig[$instance] ) ) {

			// Set up Twig environment.
			$loader_path = array();

			foreach ( $paths as $path ) {
				if ( is_dir( $path . 'twig' . DIRECTORY_SEPARATOR ) ) {
					$loader_path[] = $path . 'twig' . DIRECTORY_SEPARATOR;
				}
			}

			$loader = new Ai1ec_Twig_Loader_Filesystem( $loader_path );
			unset( $loader_path );
			// TODO: Add cache support.
			$environment = array(
				'cache'            => $this->get_cache_dir(),
				'optimizations'    => -1,   // all
				'auto_reload'      => true,
			);
			if ( AI1EC_DEBUG ) {
				$environment += array(
					'debug' => true, // produce node structure
				);
				// auto_reload never worked well
				$environment['cache'] = false;
				unset( $environment['auto_reload'] );
			}
			$environment = apply_filters(
				'ai1ec_twig_environment',
				$environment
			);

			$ai1ec_twig_environment = new Ai1ec_Twig_Environment(
					$loader,
					$environment
				);
			$ai1ec_twig_environment->set_registry( $this->_registry );

			$this->_twig[$instance] = $ai1ec_twig_environment;
			if ( apply_filters( 'ai1ec_twig_add_debug', AI1EC_DEBUG ) ) {
				$this->_twig[$instance]->addExtension( new Twig_Extension_Debug() );
			}

			$extension = $this->_registry->get( 'twig.ai1ec-extension' );
			$extension->set_registry( $this->_registry );
			$this->_twig[$instance]->addExtension( $extension );
		}
		return $this->_twig[$instance];
	}

	/**
	 * Called during 'after_setup_theme' action. Runs theme's special
	 * functions.php file, if present.
	 */
	public function execute_theme_functions() {
		$option    = $this->_registry->get( 'model.option' );
		$theme     = $option->get( 'ai1ec_current_theme' );
		$functions = $theme['theme_dir'] . DIRECTORY_SEPARATOR . 'functions.php';

		if ( file_exists( $functions ) ) {
			include( $functions );
		}
	}

	/**
	 * Safe checking for directory writeability.
	 *
	 * @param string $dir Path of likely directory.
	 *
	 * @return bool Writeability.
	 */
	private function _is_dir_writable( $dir ) {
		$stack = array(
			dirname( dirname( $dir ) ),
			dirname( $dir ),
			$dir,
		);
		foreach ( $stack as $element ) {
			if ( is_dir( $element )  ) {
				continue;
			}
			if ( ! is_writable( dirname( $element ) ) ) {
				return false;
			}
			if ( ! mkdir( $dir, 0755, true ) ) {
				return false;
			}
		}
		return true;
	}

	/**
	 * Switch to the given calendar theme.
	 *
	 * @param  array $theme            The theme's settings array
	 * @param  bool  $delete_variables If true, deletes user variables from DB.
	 *                                 Else replaces them with config file.
	 */
	public function switch_theme( array $theme, $delete_variables = true ) {
		/* @var $option Ai1ec_Option */
		$option = $this->_registry->get( 'model.option' );
		$option->set(
			'ai1ec_current_theme',
			$theme
		);
		$option->delete( 'ai1ec_fer_checked' );
		$lessphp = $this->_registry->get( 'less.lessphp' );
		// If requested, delete theme variables from DB.
		if ( $delete_variables ) {
			$option->delete( Ai1ec_Less_Lessphp::DB_KEY_FOR_LESS_VARIABLES );
		}
		// Else replace them with those loaded from config file.
		else {
			$option->set(
				Ai1ec_Less_Lessphp::DB_KEY_FOR_LESS_VARIABLES,
				$lessphp->get_less_variable_data_from_config_file()
			);
		}
		// Recompile CSS for new theme.
		$css_controller = $this->_registry->get( 'css.frontend' );
		$css_controller->invalidate_cache( null, false );
	}

	/**
	 * Switches to default Vortex theme.
	 *
	 * @param bool $silent Whether notify admin or not.
	 *
	 * @return void Method does not return.
	 */
	public function switch_to_vortex( $silent = false ) {
		$current_theme = $this->get_current_theme();
		if (
			isset( $current_theme['stylesheet'] ) &&
			'vortex' === $current_theme['stylesheet']
		) {
			return $current_theme;
		}
		$root  = AI1EC_PATH . DIRECTORY_SEPARATOR . 'public' .
			DIRECTORY_SEPARATOR . AI1EC_THEME_FOLDER;
		$theme = array(
			'theme_root' => $root,
			'theme_dir'  => $root . DIRECTORY_SEPARATOR . 'vortex',
			'theme_url'  => AI1EC_URL . '/public/' . AI1EC_THEME_FOLDER . '/vortex',
			'stylesheet' => 'vortex',
			'legacy'     => false
		);
		$this->switch_theme( $theme );
		if ( ! $silent ) {
			$this->_registry->get( 'notification.admin' )->store(
				Ai1ec_I18n::__(
					"Your calendar theme has been switched to Vortex due to a rendering problem. For more information, please enable debug mode by adding this line to your WordPress <code>wp-config.php</code> file:<pre>define( 'AI1EC_DEBUG', true );</pre>"
				),
				'error',
				0,
				array( Ai1ec_Notification_Admin::RCPT_ADMIN ),
				true
			);
		}
		return $theme;
	}

	/**
	 * Returns current calendar theme.
	 *
	 * @return mixed Theme array or null.
	 *
	 * @throws Ai1ec_Bootstrap_Exception
	 */
	public function get_current_theme() {
		return $this->_registry->get(
			'model.option'
		)->get( 'ai1ec_current_theme' );
	}

}
